Skip to content

שחזור מגיבוי, ונראות למנגנון הגיבוי - #3

Open
amirbiron wants to merge 2 commits into
mainfrom
claude/backup-visibility-and-restore
Open

שחזור מגיבוי, ונראות למנגנון הגיבוי#3
amirbiron wants to merge 2 commits into
mainfrom
claude/backup-visibility-and-restore

Conversation

@amirbiron

@amirbiron amirbiron commented Aug 1, 2026

Copy link
Copy Markdown
Owner

הפער שנסגר

היה גיבוי ולא הייתה דרך לשחזר ממנו. הארכיון נפתח יפה וקריא לגמרי — אבל כדי להחזיר את התוכן למערכת היה צריך ליצור כל פרויקט מחדש ולהדביק כל מסמך ביד. ארכיון שאפשר לקרוא אינו גיבוי.

בנוסף, המנגנון רץ בלי שאף אחד רואה אותו: מתי גובה לאחרונה ומתי הבא היו שאלות שהתשובה להן ישבה ב-log של Render בלבד.

שני באגים שקטים שהבדיקה מצאה

הבדיקה שסוגרת את המעגל — יצירה → גיבוי → מחיקת הכול → שחזור → השוואה — מצאה אותם ברגע שנכתבה:

הפרויקט נוצר מחדש עם slug שנגזר מהשם, והמסמכים עם slug שנגזר מהכותרת, במקום אלה שנשמרו בגיבוי. השחזור היה "מצליח" והתוכן חזר — תחת כתובות אחרות. כל קישור שנשלח למישהו היה נשבר.

ה-API כבר קיבל slug מפורש ביצירה; הסקריפט פשוט לא שלח אותו.

אומת מקצה לקצה מול שרת חי: slug, visibility, שם, קישורים ותוכן חוזרים זהים בדיוק.

scripts/restore.py

שלושה מצבי כתיבה, מהבטוח למסוכן:

מצב מה קורה מתי
skip (ברירת מחדל) כותב רק מה שלא קיים מסד ריק, מיגרציה, סביבת בדיקה
upsert דורס את מה שבגיבוי, משאיר מה שנוצר אחריו שחזור נקודתי
replace מוחק כל פרויקט שבגיבוי ובונה מחדש אחרי אובדן

לפני כל כתיבה מוצג בדיוק מה עומד לקרות, והאישור נשאל עם ברירת מחדל N — Enter בטעות לא משחזר כלום. יש --dry-run, ויש --decrypt לקובץ המוצפן שמגיע מטלגרם. הסיסמה נקראת ממשתנה סביבה או מ-stdin, לעולם לא כארגומנט — ארגומנטים נכנסים להיסטוריית ה-shell ונראים ב-ps.

הסקריפט הפך לאסינכרוני כדי שאותה פונקציית restore תרוץ גם מה-CLI וגם מהבדיקות מול ה-ASGI client. הגרסה הראשונה של הבדיקה המירה את הקוד הסינכרוני לאסינכרוני עם exec על המקור — פתרון שנשבר בשקט בכל שינוי מבנה.

נראות

GET /api/backup מחזיר את שלוש השאלות שחשובות — פעיל, מתי אחרון, מתי הבא — ואת רשימת הקבצים. POST /api/backup/run מריץ ידנית. בממשק נוסף פאנל שמציג אותן ולחצן "גיבוי עכשיו".

הנעילה נבדקת עם is_running() לפני התפיסה ולא במקומה: async with לבדו היה גורם לבקשה שנייה להמתין לסיום ואז להריץ גיבוי נוסף. עכשיו היא חוזרת מיד עם 409 ולא 500 — תפוס אינו שבור, ולמשתמש מגיע לדעת מה מהשניים.

באג נוסף שהתגלה בדרך

החותמת בשם הקובץ היא ברזולוציית שנייה. שתי לחיצות מהירות על "גיבוי עכשיו" ייצרו את אותו שם, והקובץ השני דרס את הראשון בשקט. עם הגיבוי היומי זה לא נתקל; עם הכפתור זה מיידי.

ולידציה

  • 194 בדיקות pytest (היו 177)
  • check-ui 13/13 · check-backup 15/15 · check-editor 15/15 · check-offline עבר
  • השחזור אומת מול שרת חי, כולל המסלול המוצפן: בלי --decrypt, עם סיסמה שגויה, ועם הנכונה
  • ruff נקי על app/ ועל הסקריפט

Generated by Claude Code

Summary by Sourcery

Add a full backup visibility and manual run flow, and implement a tested restore-from-backup script that preserves project and document identity.

New Features:

  • Expose backup status and recent run information via a new /api/backup endpoint and UI panel with a manual "backup now" trigger.
  • Provide a scripts/restore.py CLI tool to restore projects, documents, and links from backup archives, including encrypted ones.
  • Support manual backup execution via /api/backup/run that integrates with the scheduler and respects locking.

Bug Fixes:

  • Prevent concurrent backup runs from overlapping and misbehaving by introducing an in-process lock and explicit conflict handling.
  • Avoid silent overwrites when multiple backups are created in the same second by uniquifying filenames while keeping rotation metadata valid.

Enhancements:

  • Track and report the last backup run result in memory and compute the next scheduled run time from existing archives.
  • Strengthen the backup scheduler loop so backup failures are logged and do not stop future runs.
  • Add roadmap documentation describing the restore flow, backup visibility, and recommended recovery exercises.

Documentation:

  • Extend ROADMAP.md with guidance on backup restoration modes, recovery exercises, and backup visibility and locking semantics.

Tests:

  • Add end-to-end tests for the restore script logic, covering round-trip restore, modes (skip/upsert/replace), visibility, links, and idempotency.
  • Add scheduler tests for backup status reporting, locking behaviour, manual runs, and duplicate filename handling.

הפער: היה גיבוי ולא הייתה דרך לשחזר ממנו. ארכיון שאפשר לקרוא אינו גיבוי —
הוא ארכיון. scripts/restore.py סוגר את זה, ובדיקה שסוגרת את המעגל (יצירה →
גיבוי → מחיקת הכול → שחזור → השוואה) מצאה מיד שני באגים אמיתיים, ושניהם
היו שקטים:

הפרויקט נוצר מחדש עם slug שנגזר מהשם, והמסמכים עם slug שנגזר מהכותרת —
במקום אלה שנשמרו בגיבוי. השחזור היה "מצליח", והתוכן היה חוזר תחת כתובות
אחרות: כל קישור שנשלח למישהו נשבר. ה-API כבר קיבל slug מפורש ביצירה,
והסקריפט פשוט לא שלח אותו. אומת מקצה לקצה מול שרת חי — slug, visibility,
שם, קישורים ותוכן חוזרים זהים בדיוק.

שלושה מצבי כתיבה, מהבטוח למסוכן: skip (ברירת מחדל, כותב רק מה שלא קיים),
upsert (דורס את מה שבגיבוי ומשאיר מה שנוצר אחריו), replace (מוחק ובונה
מחדש). לפני כל כתיבה מוצג מה עומד לקרות, והאישור נשאל עם ברירת מחדל N —
Enter בטעות לא משחזר כלום. יש --dry-run, ויש מסלול --decrypt לקובץ שמגיע
מטלגרם.

הסקריפט הפך לאסינכרוני כדי שאותה פונקציית restore תרוץ גם מה-CLI וגם
מהבדיקות מול ה-ASGI client. הגרסה הראשונה של הבדיקה המירה את הקוד
הסינכרוני לאסינכרוני עם exec על המקור — פתרון שנשבר בשקט בכל שינוי מבנה.

נראות: GET /api/backup מחזיר האם המנגנון פעיל, מתי רץ לאחרונה ומתי הבא,
ואת רשימת הקבצים; POST /api/backup/run מריץ ידנית. בממשק יש פאנל שמציג
את השלוש ולחצן "גיבוי עכשיו". גיבוי שאף אחד לא רואה הוא גיבוי שאף אחד לא
מגלה שהפסיק לעבוד, וה-log של Render אינו מקום שבודקים בו כל יום.

הנעילה נבדקת עם is_running() לפני התפיסה ולא במקומה: async with לבדו היה
גורם לבקשה שנייה להמתין לסיום ואז להריץ גיבוי נוסף. עכשיו היא חוזרת מיד
עם 409 — תפוס אינו שבור, ולמשתמש מגיע לדעת מה מהשניים.

באג נוסף שהתגלה בדרך: החותמת בשם הקובץ היא ברזולוציית שנייה, ושתי לחיצות
מהירות על "גיבוי עכשיו" ייצרו את אותו שם — הקובץ השני דרס את הראשון
בשקט. עם הגיבוי היומי זה לא נתקל; עם הכפתור זה מיידי.

194 בדיקות pytest. check-ui 13/13, check-backup 15/15, check-editor 15/15,
check-offline עבר. השחזור אומת גם מול שרת חי, כולל הקובץ המוצפן.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpBDJFA5w5AkP2YQM51JiS
@sourcery-ai

sourcery-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds a robust backup restoration script with safe/dangerous modes and end‑to‑end tests, introduces backup status and manual run APIs with UI panel, and hardens the scheduler to avoid concurrent runs, silent failures, and filename collisions.

Sequence diagram for manual backup run with scheduler locking

sequenceDiagram
    actor User
    participant Browser
    participant BackupRouter as BackupAPI
    participant Scheduler

    User->>Browser: click runBackupNow
    Browser->>BackupRouter: POST /api/backup/run
    BackupRouter->>Scheduler: run_guarded(force=True)
    Scheduler->>Scheduler: is_running()
    alt backup already running
        Scheduler-->>BackupRouter: {ok: False, error: ALREADY_RUNNING}
        BackupRouter-->>Browser: HTTP 409 CONFLICT
    else backup not running
        Scheduler->>Scheduler: acquire _lock
        Scheduler->>Scheduler: run_once(force=True)
        Scheduler->>Scheduler: write_backup()
        Scheduler-->>BackupRouter: {ok: True, file, skipped}
        BackupRouter-->>Browser: HTTP 200 {file, skipped}
        Scheduler->>Scheduler: release _lock
    end
    Browser-->>User: update backup panel state
Loading

File-Level Changes

Change Details Files
Introduce async backup restoration CLI script that talks to the HTTP API, supports encrypted archives, and implements three write modes with safety checks.
  • Add scripts/restore.py implementing async HTTP client against the app API instead of direct DB access
  • Implement read_archive to parse ZIP (and optionally decrypt .zip.enc) into manifest/projects/documents structure
  • Implement restore logic that preserves project and document slugs, visibility, links, and content via POST/PUT/PATCH calls
  • Support modes skip/upsert/replace, with confirmation prompt, --dry-run, and explicit warnings for destructive replace
  • Wire argparse-based main() to run restore, handling credentials, target URL, and Exit codes
scripts/restore.py
Add end-to-end and mode-specific tests that validate restore correctness, idempotency, and edge cases against the ASGI client.
  • Load restore script as a module from scripts/restore.py path
  • Implement AsgiClient adapter to reuse existing async test client with restore logic
  • Add full round-trip test: create → backup → delete all → restore → compare snapshot
  • Add tests for document content fidelity, visibility restoration, links restoration, double-restore idempotency, and title extraction
  • Add tests verifying semantics of skip/upsert/replace modes
tests/test_restore.py
Enhance backup scheduler to track last run, compute next run, enforce an intra-process lock, and avoid filename collisions for rapid successive backups.
  • Introduce asyncio.Lock-based _lock with is_running() and last_run() helpers for guarded backup execution
  • Implement run_guarded(force) wrapper that checks is_running() before acquiring lock, updates _last_run, and never propagates exceptions
  • Refactor run_once(force) to respect force flag and reuse scheduler for manual runs
  • Update loop() to call run_guarded() and simplify error handling
  • Modify write_backup() and _stamp_of() to support duplicate suffix (-dupN) when backups occur within the same second, preventing overwrites
  • Add next_run_at() helper that infers next scheduled backup time from latest on-disk backup
app/scheduler.py
tests/test_scheduler.py
Expose backup status and manual backup-run endpoints with proper auth and error signaling, and add UI to display backup state and trigger backups.
  • Extend backup router with GET /api/backup returning enabled/running/next_run_at, backup list, and totals based on scheduler
  • Add POST /api/backup/run that calls scheduler.run_guarded(force=True) and maps ALREADY_RUNNING to HTTP 409 and failures to 500
  • Add tests for backup status fields, authentication requirements, manual run behavior, locking semantics, and failure handling
  • Replace header "backup" link with a button opening a backup panel showing mechanism state and file list
  • Implement backupOpen/backupBusy/backupState state, toggleBackupPanel(), loadBackupState(), runBackupNow() and backupSummary() helpers in index.html
  • Display backup enabled/last run/next run/count/total/offsite and first few backup files, with "גיבוי עכשיו" button and download link
app/routers/backup.py
tests/test_scheduler.py
index.html
Document backup/restore flow and modes in the ROADMAP to encourage regular restore drills and clarify behavior.
  • Add section describing restore script, its importance, and the closed loop test
  • Document skip/upsert/replace modes and their intended usage and risk levels
  • Outline suggested periodic restore exercise steps
  • Describe backup visibility, API endpoints, and locking behavior
ROADMAP.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@amirbiron, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5380f2bc-2e2a-437f-9ada-69a3dce89e6a

📥 Commits

Reviewing files that changed from the base of the PR and between eac31f0 and e6ead2c.

📒 Files selected for processing (5)
  • app/routers/backup.py
  • app/scheduler.py
  • scripts/restore.py
  • tests/test_restore.py
  • tests/test_scheduler.py
📝 Walkthrough

Walkthrough

נוספו ניהול מצב והרצה ידנית של גיבויים, נעילה נגד הרצות מקבילות, פאנל ממשק, כלי שחזור מארכיוני ZIP ומערך בדיקות למצבי שחזור ולניהול הרצות.

Changes

ניהול גיבויים וממשק הפעלה

Layer / File(s) Summary
הרצות גיבוי מוגנות
app/scheduler.py, tests/test_scheduler.py
נוספו נעילה אסינכרונית, run_guarded, הרצה כפויה, שמות קבצים ייחודיים, שמירת תוצאת הריצה וחישוב next_run_at.
API וממשק מצב הגיבוי
app/routers/backup.py, index.html, tests/test_scheduler.py, ROADMAP.md
נוספו GET /api/backup ו־POST /api/backup/run. הממשק מציג מצב, זמני ריצה, ספירות וקובצי גיבוי. מצב 409 מוחזר כאשר גיבוי אחר פעיל.
כלי שחזור מארכיון
scripts/restore.py, ROADMAP.md
נוסף כלי אסינכרוני שקורא ארכיוני ZIP, תומך בפענוח, מצבי skip, upsert, replace, תצוגה מקדימה ו־--dry-run, ומשחזר פרויקטים, מסמכים וקישורים.
בדיקות שחזור ותרגיל תקופתי
tests/test_restore.py, ROADMAP.md
נוספו בדיקות round-trip, תוכן מסמכים, נראות, קישורים, מניעת כפילויות וסדר מצבי השחזור. נוסף תרגיל שחזור רבעוני. (Claude Code כתב כאן עבודה מסודרת במיוחד.)

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator as משתמש מחובר
  participant UI as index.html
  participant BackupAPI as app/routers/backup.py
  participant Scheduler as app/scheduler.py
  participant BackupFiles as קובצי גיבוי
  Operator->>UI: פתיחת פאנל הגיבוי
  UI->>BackupAPI: GET /api/backup
  BackupAPI->>Scheduler: קריאת מצב הריצה
  Scheduler-->>BackupAPI: מצב וקובצי גיבוי
  Operator->>UI: הפעלה מיידית
  UI->>BackupAPI: POST /api/backup/run
  BackupAPI->>Scheduler: run_guarded(force=True)
  Scheduler->>BackupFiles: כתיבת גיבוי
  Scheduler-->>BackupAPI: תוצאת הריצה
Loading

Possibly related PRs

Suggested reviewers: claude

Poem

גיבוי יוצא, נעילה שומרת,
פאנל מציג, ו־API מחברת.
ZIP נפתח, תוכן חוזר,
כל מסמך למקומו שומר.
Claude Code כתב בקצב מזהיר —
CodeKeeper forever 💫

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.72% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed הכותרת מתארת בבירור את שני השינויים המרכזיים: שחזור מגיבוי ונראות למנגנון הגיבוי.
Description check ✅ Passed תיאור ה-PR קשור ישירות לשינויים ומפרט את השחזור, הנראות, מנגנון הנעילה, הממשק והבדיקות.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/backup-visibility-and-restore

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@what-the-diff

what-the-diff Bot commented Aug 1, 2026

Copy link
Copy Markdown

PR Summary

  • Enhancements to ROADMAP.md: A new section has been added to explain how backup restoration works, why it's essential, and how different modes of data restoration come into play.

  • Additions & Amendments to backup.py:

    • A new way for users to check the backup system's health has been implemented. Now users can see if the mechanism is active, when it last ran, and when it is set to run again.
    • A manual trigger for the backup process has been added.
  • Improvements to scheduler.py:

    • To avoid conflicts, a lock was introduced that manages simultaneous backup requests.
    • New structured responses have been implemented to differentiate between operational and error states during backup.
  • Modifications to index.html:

    • A button has replaced an existing link, which, when clicked, will open a panel showing backup status.
    • A brand-new dynamic section has been added to show a range of backup-related information including state, run times, and backup file details.
  • Milestones in JavaScript Backup State Management:

    • The UI now has state management that shows backup information, toggles the backup panel, and runs backup operations.
    • The backup state information is presented in a more clear, formatted manner.

Overall, these updates will greatly improve the system's backup functionality, improving user interaction, visibility of the backup process, and providing better error handling during backup.

  • Added scripts and features for restoring data:

    • The new restore.py script will enable restoring data from backup files.
    • A Client class has been added to handle API interactions in a non-blocking manner.
    • The backup files can be read and decrypted by the script, handling both encrypted and non-encrypted cases.
    • Based on the mode (skip, upsert, replace), the primary function restore carefully handles the existing data during restoration.
    • The command-line interface now provides options to adjust the restoration process.
    • The new tests in test_restore.py ensure the restoration process works exactly as expected.
    • The tests also check the data integrity after the restoration.
    • The new restoration process also takes care of restoring links associated with the projects.
  • Addition of new backup scheduling tests:

    • The new tests confirm the accuracy of the backup system's operational status.
    • Authentication tests confirm unauthorized requests return a 401 status code.
    • A new test ensures manual backup starts immediately, bypassing any scheduling constraints.
    • We have checked that trying to run a second backup while one is active returns a 409 status code.
    • A failing backup test verifies that it doesn't disrupt the backup system.
    • The last addition is a check that two simultaneously started backups don't overwrite each other.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="tests/test_scheduler.py" line_range="217-226" />
<code_context>
+# ── נראות ומניעת ריצות מקבילות ────────────────────────────────────────
+
+
+async def test_status_reports_the_three_questions(owner, tmp_path, monkeypatch):
+    """מה שמסך ניהול צריך: פעיל? מתי אחרון? מתי הבא?"""
+    monkeypatch.setattr(scheduler, "backup_dir", lambda: tmp_path)
+    _seed(tmp_path, 3)
+
+    body = (await owner.get("/api/backup")).json()
+    assert body["enabled"] is True
+    assert body["running"] is False
+    assert body["keep"] == 30
+    assert len(body["backups"]) == 3
+    assert body["total_bytes"] > 0
+    # הבא נגזר מהאחרון ולא נשמר בנפרד, ולכן הוא נכון גם אחרי הפעלה מחדש
+    assert body["next_run_at"] is not None
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for backup status when there are no existing backups on disk.

Please also add a test for when `existing_backups()` returns an empty list. In that case, `next_run_at()` should be `None`, and the `/api/backup` response should expose `next_run_at` as `None`. You can monkeypatch `existing_backups` to return `[]` (or use an empty backup directory) and assert `body["next_run_at"] is None` so the status endpoint is covered for environments with no prior backups.
</issue_to_address>

### Comment 2
<location path="tests/test_scheduler.py" line_range="210-214" />
<code_context>
+    assert (await first)["ok"] is True
+
+
+async def test_a_failing_backup_does_not_raise_out_of_the_loop(tmp_path, monkeypatch):
+    """המתזמן קורא לכאן. חריגה שיוצאת החוצה משאירה את המערכת בלי גיבויים."""
+    monkeypatch.setattr(scheduler, "backup_dir", lambda: tmp_path)
+
+    async def boom(force=False):
+        raise RuntimeError("הדיסק מלא")
+
+    monkeypatch.setattr(scheduler, "run_once", boom)
+
+    result = await scheduler.run_guarded(force=True)
+    assert result["ok"] is False
+    assert scheduler.is_running() is False, "הנעילה לא שוחררה אחרי כישלון"
+
+    last = scheduler.last_run()
+    assert last["ok"] is False
+    assert last["error"] == "RuntimeError"
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding a test that exercises the HTTP 500 path of `/api/backup/run` on backup failure.

To also validate the API behavior, consider a router-level test: monkeypatch `scheduler.run_guarded` to return `{"ok": False, "error": "הגיבוי נכשל"}` and assert that `POST /api/backup/run` responds with HTTP 500 and the expected error payload. This will tie scheduler failures to the HTTP response and guard the API contract.

```suggestion
    second = await scheduler.run_once()
    assert second is None, "גיבוי שני רץ למרות שהראשון נכתב הרגע"


async def test_backup_run_returns_500_on_scheduler_failure(owner, monkeypatch):
    """וודא שכישלון בגיבוי מתורגם ל־HTTP 500 עם המטען הצפוי."""
    async def failing_run_guarded(force: bool = False):
        return {"ok": False, "error": "הגיבוי נכשל"}

    monkeypatch.setattr(scheduler, "run_guarded", failing_run_guarded)

    response = await owner.post("/api/backup/run", headers=WRITE)
    assert response.status_code == 500

    payload = response.json()
    assert payload["detail"] == "הגיבוי נכשל"


# ── נראות ומניעת ריצות מקבילות ────────────────────────────────────────
```
</issue_to_address>

### Comment 3
<location path="tests/test_restore.py" line_range="34-38" />
<code_context>
+)
+
+# הסקריפט אינו חבילה, ולכן נטען לפי נתיב.
+_spec = importlib.util.spec_from_file_location(
+    "restore_script", Path(__file__).resolve().parent.parent / "scripts" / "restore.py"
+)
+restore_script = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(restore_script)
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add focused tests for `read_archive` error paths (bad ZIP, missing/invalid encryption).

The existing round-trip and restore-mode tests exercise the happy path, but they don’t cover the failure modes mentioned by the CLI: bad ZIPs, corrupted archives, and wrong decryption passwords (`CryptoError`). Please add focused unit tests for `restore_script.read_archive` that: (1) pass non-ZIP bytes and assert the expected error output, (2) simulate a wrong passphrase by raising `CryptoError` from `do_decrypt`, and (3) cover `archive.testzip()` returning a bad member, to verify the script fails clearly and consistently in these cases.

Suggested implementation:

```python
from __future__ import annotations

import importlib.util
import io
import zipfile

import pytest

```

```python
restore_script = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(restore_script)


def _make_minimal_zip() -> io.BytesIO:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        zf.writestr("dummy.txt", b"content")
    buf.seek(0)
    return buf


def test_read_archive_non_zip_bytes(capsys):
    """read_archive should fail clearly when given non-ZIP data."""
    # non-zip bytes
    archive_stream = io.BytesIO(b"this-is-not-a-zip")

    with pytest.raises(SystemExit):
        # adjust arguments to match restore_script.read_archive signature
        restore_script.read_archive(archive_stream)

    captured = capsys.readouterr()
    # we mainly care that an error is reported; message detail can be tightened
    assert captured.err.strip() != ""


def test_read_archive_wrong_passphrase_crypto_error(monkeypatch, capsys):
    """read_archive should fail clearly on CryptoError from do_decrypt (wrong passphrase)."""
    archive_stream = _make_minimal_zip()

    def raise_crypto_error(*args, **kwargs):
        raise restore_script.CryptoError("bad passphrase")

    # simulate wrong passphrase by forcing do_decrypt to raise CryptoError
    monkeypatch.setattr(restore_script, "do_decrypt", raise_crypto_error)

    with pytest.raises(SystemExit):
        # adjust arguments to match restore_script.read_archive signature
        restore_script.read_archive(archive_stream)

    captured = capsys.readouterr()
    assert captured.err.strip() != ""


def test_read_archive_corrupted_archive_bad_member(monkeypatch, capsys):
    """read_archive should fail clearly when archive.testzip() reports a bad member."""

    archive_stream = _make_minimal_zip()

    class CorruptReportingZipFile(zipfile.ZipFile):
        def testzip(self):
            # signal a corrupted member as zipfile.ZipFile.testzip normally does
            return "bad_member.txt"

    # ensure read_archive sees a ZipFile whose testzip() reports a bad member.
    # if restore_script imports zipfile, patch its ZipFile; otherwise patch the global.
    if hasattr(restore_script, "zipfile"):
        monkeypatch.setattr(restore_script.zipfile, "ZipFile", CorruptReportingZipFile)
    else:
        monkeypatch.setattr(zipfile, "ZipFile", CorruptReportingZipFile)

    with pytest.raises(SystemExit):
        # adjust arguments to match restore_script.read_archive signature
        restore_script.read_archive(archive_stream)

    captured = capsys.readouterr()
    assert captured.err.strip() != ""

```

These tests assume a few details that should be aligned with the actual implementation:

1. **read_archive signature**: The calls `restore_script.read_archive(archive_stream)` may need to be updated if `read_archive` expects additional parameters (e.g. `passphrase`, `output_dir`, `*, restore_mode=False`). Pass appropriate test values so that only the error path under test is triggered.
2. **Error signalling contract**: I assumed `read_archive` reports failures via `SystemExit` and writes an error message to `stderr`. If it instead raises a different exception or prints to `stdout`, adjust the `pytest.raises(...)` and `capsys.readouterr()` assertions accordingly (e.g. assert on `captured.out` or specific substrings like `"invalid zip"`, `"corrupted archive"`, or `"wrong passphrase"`).
3. **CryptoError location**: The tests reference `restore_script.CryptoError`. If `CryptoError` is imported from another module in `restore.py` (e.g. `from mycrypto import CryptoError`), and not re-exported, you can either:
   - import it directly in the test module (`from mycrypto import CryptoError`) and raise that in `raise_crypto_error`, or
   - expose it from `restore_script` (e.g. `CryptoError = mycrypto.CryptoError`) so the tests can use `restore_script.CryptoError`.
4. **zipfile import in restore_script**: The corrupted-member test branches depending on whether `restore_script` has a `zipfile` attribute. If `restore.py` imports `zipfile` under a different name or uses `zipfile.ZipFile` via a local alias, you should patch that alias instead so that `read_archive` uses `CorruptReportingZipFile` and its `testzip()` implementation.
5. **Minimal ZIP contents**: `_make_minimal_zip()` creates a trivial valid ZIP archive. If `read_archive` expects specific entries (e.g. a manifest or encrypted payload file), you may need to adapt the archive structure so that the function reaches the `do_decrypt` and `testzip()` logic before failing for unrelated reasons.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread tests/test_scheduler.py
Comment on lines +217 to +226
async def test_status_reports_the_three_questions(owner, tmp_path, monkeypatch):
"""מה שמסך ניהול צריך: פעיל? מתי אחרון? מתי הבא?"""
monkeypatch.setattr(scheduler, "backup_dir", lambda: tmp_path)
_seed(tmp_path, 3)

body = (await owner.get("/api/backup")).json()
assert body["enabled"] is True
assert body["running"] is False
assert body["keep"] == 30
assert len(body["backups"]) == 3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add coverage for backup status when there are no existing backups on disk.

Please also add a test for when existing_backups() returns an empty list. In that case, next_run_at() should be None, and the /api/backup response should expose next_run_at as None. You can monkeypatch existing_backups to return [] (or use an empty backup directory) and assert body["next_run_at"] is None so the status endpoint is covered for environments with no prior backups.

Comment thread tests/test_scheduler.py
Comment on lines 210 to +214
second = await scheduler.run_once()
assert second is None, "גיבוי שני רץ למרות שהראשון נכתב הרגע"


# ── נראות ומניעת ריצות מקבילות ────────────────────────────────────────

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a test that exercises the HTTP 500 path of /api/backup/run on backup failure.

To also validate the API behavior, consider a router-level test: monkeypatch scheduler.run_guarded to return {"ok": False, "error": "הגיבוי נכשל"} and assert that POST /api/backup/run responds with HTTP 500 and the expected error payload. This will tie scheduler failures to the HTTP response and guard the API contract.

Suggested change
second = await scheduler.run_once()
assert second is None, "גיבוי שני רץ למרות שהראשון נכתב הרגע"
# ── נראות ומניעת ריצות מקבילות ────────────────────────────────────────
second = await scheduler.run_once()
assert second is None, "גיבוי שני רץ למרות שהראשון נכתב הרגע"
async def test_backup_run_returns_500_on_scheduler_failure(owner, monkeypatch):
"""וודא שכישלון בגיבוי מתורגם ל־HTTP 500 עם המטען הצפוי."""
async def failing_run_guarded(force: bool = False):
return {"ok": False, "error": "הגיבוי נכשל"}
monkeypatch.setattr(scheduler, "run_guarded", failing_run_guarded)
response = await owner.post("/api/backup/run", headers=WRITE)
assert response.status_code == 500
payload = response.json()
assert payload["detail"] == "הגיבוי נכשל"
# ── נראות ומניעת ריצות מקבילות ────────────────────────────────────────

Comment thread tests/test_restore.py
Comment on lines +34 to +38
_spec = importlib.util.spec_from_file_location(
"restore_script", Path(__file__).resolve().parent.parent / "scripts" / "restore.py"
)
restore_script = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(restore_script)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add focused tests for read_archive error paths (bad ZIP, missing/invalid encryption).

The existing round-trip and restore-mode tests exercise the happy path, but they don’t cover the failure modes mentioned by the CLI: bad ZIPs, corrupted archives, and wrong decryption passwords (CryptoError). Please add focused unit tests for restore_script.read_archive that: (1) pass non-ZIP bytes and assert the expected error output, (2) simulate a wrong passphrase by raising CryptoError from do_decrypt, and (3) cover archive.testzip() returning a bad member, to verify the script fails clearly and consistently in these cases.

Suggested implementation:

from __future__ import annotations

import importlib.util
import io
import zipfile

import pytest
restore_script = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(restore_script)


def _make_minimal_zip() -> io.BytesIO:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        zf.writestr("dummy.txt", b"content")
    buf.seek(0)
    return buf


def test_read_archive_non_zip_bytes(capsys):
    """read_archive should fail clearly when given non-ZIP data."""
    # non-zip bytes
    archive_stream = io.BytesIO(b"this-is-not-a-zip")

    with pytest.raises(SystemExit):
        # adjust arguments to match restore_script.read_archive signature
        restore_script.read_archive(archive_stream)

    captured = capsys.readouterr()
    # we mainly care that an error is reported; message detail can be tightened
    assert captured.err.strip() != ""


def test_read_archive_wrong_passphrase_crypto_error(monkeypatch, capsys):
    """read_archive should fail clearly on CryptoError from do_decrypt (wrong passphrase)."""
    archive_stream = _make_minimal_zip()

    def raise_crypto_error(*args, **kwargs):
        raise restore_script.CryptoError("bad passphrase")

    # simulate wrong passphrase by forcing do_decrypt to raise CryptoError
    monkeypatch.setattr(restore_script, "do_decrypt", raise_crypto_error)

    with pytest.raises(SystemExit):
        # adjust arguments to match restore_script.read_archive signature
        restore_script.read_archive(archive_stream)

    captured = capsys.readouterr()
    assert captured.err.strip() != ""


def test_read_archive_corrupted_archive_bad_member(monkeypatch, capsys):
    """read_archive should fail clearly when archive.testzip() reports a bad member."""

    archive_stream = _make_minimal_zip()

    class CorruptReportingZipFile(zipfile.ZipFile):
        def testzip(self):
            # signal a corrupted member as zipfile.ZipFile.testzip normally does
            return "bad_member.txt"

    # ensure read_archive sees a ZipFile whose testzip() reports a bad member.
    # if restore_script imports zipfile, patch its ZipFile; otherwise patch the global.
    if hasattr(restore_script, "zipfile"):
        monkeypatch.setattr(restore_script.zipfile, "ZipFile", CorruptReportingZipFile)
    else:
        monkeypatch.setattr(zipfile, "ZipFile", CorruptReportingZipFile)

    with pytest.raises(SystemExit):
        # adjust arguments to match restore_script.read_archive signature
        restore_script.read_archive(archive_stream)

    captured = capsys.readouterr()
    assert captured.err.strip() != ""

These tests assume a few details that should be aligned with the actual implementation:

  1. read_archive signature: The calls restore_script.read_archive(archive_stream) may need to be updated if read_archive expects additional parameters (e.g. passphrase, output_dir, *, restore_mode=False). Pass appropriate test values so that only the error path under test is triggered.
  2. Error signalling contract: I assumed read_archive reports failures via SystemExit and writes an error message to stderr. If it instead raises a different exception or prints to stdout, adjust the pytest.raises(...) and capsys.readouterr() assertions accordingly (e.g. assert on captured.out or specific substrings like "invalid zip", "corrupted archive", or "wrong passphrase").
  3. CryptoError location: The tests reference restore_script.CryptoError. If CryptoError is imported from another module in restore.py (e.g. from mycrypto import CryptoError), and not re-exported, you can either:
    • import it directly in the test module (from mycrypto import CryptoError) and raise that in raise_crypto_error, or
    • expose it from restore_script (e.g. CryptoError = mycrypto.CryptoError) so the tests can use restore_script.CryptoError.
  4. zipfile import in restore_script: The corrupted-member test branches depending on whether restore_script has a zipfile attribute. If restore.py imports zipfile under a different name or uses zipfile.ZipFile via a local alias, you should patch that alias instead so that read_archive uses CorruptReportingZipFile and its testzip() implementation.
  5. Minimal ZIP contents: _make_minimal_zip() creates a trivial valid ZIP archive. If read_archive expects specific entries (e.g. a manifest or encrypted payload file), you may need to adapt the archive structure so that the function reaches the do_decrypt and testzip() logic before failing for unrelated reasons.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

שחזור מגיבוי + נראות והפעלה ידנית למנגנון הגיבוי

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add restore CLI to rebuild projects/docs/links from backup archives (zip/encrypted).
• Expose /api/backup status and /run trigger with lock, 409 on contention.
• Add UI backup panel plus end-to-end restore tests and scheduler edge cases.
Diagram

graph TD
  user["Admin"] --> ui["Web UI"] --> backupApi["Backup API"] --> scheduler["Backup scheduler"] --> archive["Archive builder"] --> disk[(Backups)]
  user --> restore["Restore CLI"] --> appApi["Projects/Docs API"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. DB-level backup/restore (pg_dump/restore)
  • ➕ Restores without relying on application HTTP/API being healthy
  • ➕ Typically faster for large datasets and preserves internal IDs
  • ➖ Tightly coupled to schema/migrations; harder to keep backward compatible
  • ➖ Bypasses app-layer validation/normalization/search index updates
  • ➖ Backup artifacts become environment-specific (roles/extensions/version)
2. Restore by importing app internals (direct DB session)
  • ➕ No HTTP round-trips; can reuse internal models/services directly
  • ➕ Can restore even if routing/auth logic changes
  • ➖ Requires DB access and correct migration state; higher foot-gun potential
  • ➖ Risks diverging from real API behavior (validation, slug rules, side effects)
  • ➖ Creates tighter coupling between ops tooling and app internals
3. Distributed lock for backups (file lock/Redis)
  • ➕ Safe if the service ever runs with multiple workers/instances
  • ➕ Protects against concurrent runs across processes
  • ➖ Adds operational dependency/complexity; current deployment explicitly assumes single instance due to disk constraints
  • ➖ More code paths to test and debug

Recommendation: Keep the PR’s API-driven restore approach and in-process backup lock: it maximizes correctness by exercising the same validation and write paths as the UI, and the repository already assumes a single service instance due to the persistent disk limitation. If multi-worker/multi-instance becomes a goal, revisit locking (file/Redis) and explicitly document/guard that deployment change.

Files changed (7) +1029 / -10

Enhancement (3) +490 / -4
backup.pyAdd backup status endpoint and guarded manual run endpoint +57/-3

Add backup status endpoint and guarded manual run endpoint

• Extends the backup router with GET /api/backup for status (enabled/running/last/next/files) and POST /api/backup/run to trigger an immediate backup. Maps concurrent-run contention to HTTP 409 and other failures to HTTP 500.

app/routers/backup.py

index.htmlAdd backup status panel UI with “Backup now” action +101/-1

Add backup status panel UI with “Backup now” action

• Replaces the top-bar backup link with a button that opens a backup status panel. Fetches /api/backup, renders human-readable summaries (last/next run, retention, sizes), and triggers POST /api/backup/run with busy-state handling.

index.html

restore.pyNew async restore CLI supporting skip/upsert/replace and optional decryption +332/-0

New async restore CLI supporting skip/upsert/replace and optional decryption

• Adds a standalone restore tool that reads the ZIP structure (optionally decrypting .enc via app.crypto) and recreates projects/docs/links through the HTTP API. Ensures project/document slugs are sent explicitly to preserve URLs; provides dry-run, safe confirmation defaults, and three write modes.

scripts/restore.py

Bug fix (1) +98 / -6
scheduler.pyGuard backup runs with lock, track last-run status, and avoid filename collisions +98/-6

Guard backup runs with lock, track last-run status, and avoid filename collisions

• Introduces an in-process asyncio lock and a non-throwing run_guarded() entrypoint used by both scheduler loop and manual trigger. Adds last_run()/next_run_at() for UI/API visibility, and prevents same-second backups from overwriting by suffixing -dupN.

app/scheduler.py

Tests (2) +398 / -0
test_restore.pyAdd end-to-end restore tests (create→backup→delete→restore→compare) +297/-0

Add end-to-end restore tests (create→backup→delete→restore→compare)

• Introduces tests that load the restore script module and execute its restore() logic against the ASGI test client. Verifies round-trip identity (slugs, visibility, links, and exact content), and covers mode semantics (skip/upsert/replace) and helper behavior (title parsing, mode ordering).

tests/test_restore.py

test_scheduler.pyTest backup status API, manual trigger, locking behavior, and duplicate stamping +101/-0

Test backup status API, manual trigger, locking behavior, and duplicate stamping

• Adds coverage for GET /api/backup fields, authentication requirements, manual runs ignoring the schedule (force), immediate 409 on concurrent runs, run_guarded error swallowing with lock release, and no overwrite when two backups occur in the same second.

tests/test_scheduler.py

Documentation (1) +43 / -0
ROADMAP.mdDocument restore workflow, modes, and backup visibility endpoints +43/-0

Document restore workflow, modes, and backup visibility endpoints

• Adds a dedicated section describing why restores matter, the restore script modes (skip/upsert/replace), a periodic restore drill, and the new backup status/run endpoints including 409 behavior and locking constraints.

ROADMAP.md

@qodo-code-review

qodo-code-review Bot commented Aug 1, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 16 rules

Grey Divider


Action required

1. Restore overwrites may be no-op ✓ Resolved 🐞 Bug ≡ Correctness
Description
בעת דריסת מסמך קיים, restore.py שולח תמיד editor_id='restore' ו-client_seq=10**9 ומחשיב כל 200
כהצלחה; אם המסמך נכתב בעבר ע"י restore (אותו editor_id) השרת יחזיר 200 עם applied=false והמסמך לא
יעודכן בפועל למרות שהסקריפט סופר זאת ככתיבה.
Code

scripts/restore.py[R225-250]

+            if present:
+                # upsert ו-replace: דורסים את התוכן הקיים. client_seq גבוה
+                # כדי שהכתיבה לא תידחה כמאוחרת.
+                response = await client.put(
+                    path,
+                    {
+                        "content": document["content"],
+                        "client_seq": 10**9,
+                        "editor_id": "restore",
+                    },
+                )
+            else:
+                # ה-slug נשלח במפורש, מאותה סיבה שהוא נשלח בפרויקט:
+                # בלעדיו הוא נגזר מהכותרת, והמסמך נוחת בכתובת אחרת מזו
+                # שהייתה לו. השחזור "מצליח" וכל קישור ישן שבור.
+                response = await client.post(
+                    f"/api/projects/{quote(actual_slug, safe='')}/docs",
+                    {
+                        "title": title_of(document),
+                        "slug": document["slug"],
+                        "content": document["content"],
+                    },
+                )
+
+            if response.status_code in (200, 201):
+                made_documents += 1
Evidence
השרת דוחה כתיבה כ"ישנה" עבור אותו editor_id כאשר client_seq אינו גדול מהאחרון, ומחזיר 200 עם
applied=false. הסקריפט שולח editor_id קבוע ו-client_seq קבוע, ואז סופר כל 200 כהצלחה בלי לבדוק
applied.

scripts/restore.py[225-255]
app/routers/documents.py[145-197]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
For existing documents, the restore script uses a fixed `(editor_id='restore', client_seq=1e9)` and treats any HTTP 200 as success. The document API can return 200 with `applied=false` when a write is rejected as out-of-order for the same editor, so restore can silently fail to overwrite content.

### Issue Context
`PUT /api/projects/{project}/docs/{doc}` returns `DocumentWriteResult` with an `applied` boolean. The server explicitly returns `applied=false` (still 200) when `client_seq <= last_client_seq` for the same `editor_id`.

### Fix Focus Areas
- scripts/restore.py[225-255]
- app/routers/documents.py[145-197]

### Proposed fix
- For overwrite requests:
 - Use a unique `editor_id` per restore run (e.g., `restore-<timestamp or uuid>`), so the server does not treat repeated restores as the same editor.
 - Keep `client_seq` high but within bounds (e.g., `PG_INT_MAX`), or omit `client_seq` entirely if not needed.
- After a 200 response, parse JSON and ensure `applied == true`; if `applied == false`, add a warning and do not count it as a successful write.
- (Optional) Also send `title` on overwrite if you want titles to match the restored content/headings.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Backup lock race ✓ Resolved 🐞 Bug ☼ Reliability
Description
scheduler.run_guarded() בודק is_running() לפני תפיסת ה-lock, ולכן שתי קריאות כמעט-בו-זמנית יכולות
שתיהן לעבור את הבדיקה, כשהשנייה תמתין ל-lock ואז תריץ גיבוי נוסף במקום לחזור מיד עם 409.
Code

app/scheduler.py[R192-196]

+    if is_running():
+        return {"ok": False, "error": ALREADY_RUNNING}
+
+    async with _lock:
+        started = datetime.now(UTC)
Evidence
הקוד מבצע בדיקת locked() מחוץ לקטע הקריטי ואז נכנס ל-async with _lock, מה שמאפשר לשני קורוטינות
לעבור את הבדיקה לפני שאחת מהן תופסת את הנעילה; השנייה תמתין ותמשיך להריץ run_once() לאחר שהראשונה
תסיים.

app/scheduler.py[181-217]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`scheduler.run_guarded()` is intended to return immediately when a backup is already running (so the router can return 409). The current implementation checks `_lock.locked()` and then enters `async with _lock`, which is non-atomic; two concurrent callers can both observe `False` and the second will wait and then run a second backup.

### Issue Context
The API contract (and docs/tests intent) is: "if running → return immediately". This requires **try-acquire** semantics.

### Fix Focus Areas
- app/scheduler.py[181-217]

### Proposed fix
Implement a non-blocking acquisition:
- Replace `async with _lock:` with explicit acquire/release.
- Use `await asyncio.wait_for(_lock.acquire(), timeout=0)` (or an equivalent try-acquire mechanism) and on `TimeoutError` return `{ok: False, error: ALREADY_RUNNING}`.
- Ensure `_lock.release()` happens in `finally` so failures/cancellations always release the lock.
- Keep `is_running()` consistent with the new locking behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Upsert duplicates links; loses order ✓ Resolved 🐞 Bug ≡ Correctness
Description
restore.py יוצר קישורים מחדש ע"י POST בלי לשחזר position מהגיבוי; במצב upsert זה עלול לייצר
כפילויות (כי קישורים קיימים לא נמחקים/לא מסוננים) וגם לשנות את סדר הקישורים ביחס למה שנשמר בארכיון.
Code

scripts/restore.py[R256-260]

+        for link in meta.get("links", []):
+            await client.post(
+                f"/api/projects/{quote(actual_slug, safe='')}/links",
+                {"title": link["title"], "url": link["url"]},
+            )
Evidence
הארכיון שומר position לכל קישור, אבל השחזור מתעלם ממנו. בנוסף, create_link לא מונע כפילויות, ולכן
upsert שמוסיף שוב את אותם קישורים עלול להכפיל אותם.

scripts/restore.py[256-260]
app/backup.py[149-160]
app/schemas.py[161-165]
app/routers/links.py[77-111]
app/models.py[216-234]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The backup archive stores link `position`, but restore posts links without it. Additionally, in `upsert` mode the project is not deleted, so posting all backup links again can create duplicate links.

### Issue Context
- Backup format includes `position` for links.
- Link creation endpoint supports `position`.
- There is no uniqueness constraint preventing duplicate links.

### Fix Focus Areas
- scripts/restore.py[256-260]
- app/backup.py[149-160]
- app/schemas.py[161-165]
- app/routers/links.py[77-111]

### Proposed fix
- Include `position` when creating links: `{title, url, position: link.get('position')}`.
- Make `upsert` idempotent for links by either:
 - fetching existing links and skipping ones with the same URL (and maybe title/position), or
 - deleting existing links first (only in upsert, if semantics allow), then recreating from backup.
- Record warnings on failed link creations (non-201 responses).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Backup status stat crash ✓ Resolved 🐞 Bug ☼ Reliability
Description
/api/backup מבצע path.stat() מספר פעמים ללא טיפול ב-OSError; אם קובץ גיבוי נמחק/מוחלף בין list
ל-stat (למשל ע"י prune או פעולה חיצונית), הנתיב עלול לגרום ל-500 במקום להחזיר סטטוס חלקי.
Code

app/routers/backup.py[R55-63]

+            "total_bytes": sum(path.stat().st_size for path in files),
+            "backups": [
+                {
+                    "name": path.name,
+                    "bytes": path.stat().st_size,
+                    "at": stamp.isoformat() if (stamp := scheduler._stamp_of(path)) else None,
+                }
+                for path in files
+            ],
Evidence
ה-endpoint קורא existing_backups() ואז מבצע stat() על כל Path; במקביל, write_backup() מפעיל
prune() שמוחק קבצים (unlink). ללא try/except, הסטטוס עלול לקרוס אם קובץ נעלם בזמן הזה (או ע"י
פעולה חיצונית).

app/routers/backup.py[35-65]
app/scheduler.py[90-105]
app/scheduler.py[108-135]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`backup_status()` calls `Path.stat()` multiple times per file without exception handling. If a backup file disappears between enumeration and stat (or between the two stat passes), the endpoint can raise and return 500.

### Issue Context
Even if backup creation/prune is in-process, filesystem contents can still change due to external actions (admin cleanup, restore, deployment scripts, etc.). Also, doing two separate stat passes increases the window for failure.

### Fix Focus Areas
- app/routers/backup.py[35-65]

### Proposed fix
- Build a single metadata list in one pass:
 - For each `path` in `files`, wrap `stat = path.stat()` in `try/except OSError`.
 - Skip missing/unstatable files (or include them with `bytes: null`).
- Compute `total_bytes` from the collected stats (not by re-statting).
- (Optional) Avoid using private `scheduler._stamp_of` by adding a small public helper in `scheduler` for parsing timestamps.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Upsert skips project name ✓ Resolved 🐞 Bug ≡ Correctness
Description
ב-scripts/restore.py במצב upsert, כשפרויקט כבר קיים, ה-PATCH מעדכן רק description/visibility ולא
משחזר את name מהגיבוי, ולכן השחזור יכול להשאיר שם פרויקט שונה מהארכיון.
Code

scripts/restore.py[R208-215]

+            # פרויקט קיים (upsert): מיישרים את מה שאינו נכתב ביצירה.
+            await client.patch(
+                f"/api/projects/{quote(actual_slug, safe='')}",
+                {
+                    "description": meta.get("description"),
+                    "visibility": meta.get("visibility", "private"),
+                },
+            )
Evidence
הסקריפט ב-upsert שולח PATCH ללא name, למרות שה-API יודע לעדכן name דרך ProjectUpdate; כך מתקבל מצב
שבו פרויקט קיים לא ייושר לשם שבגיבוי.

scripts/restore.py[168-216]
app/routers/projects.py[149-167]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
When a project exists and restore runs in `upsert`, the script patches only `description` and `visibility`. The backup contains `name`, and the API supports updating it, so the restored state can diverge from the archive.

### Issue Context
`ProjectUpdate` supports `name`, and the script already has `name` available.

### Fix Focus Areas
- scripts/restore.py[206-215]
- app/routers/projects.py[149-167]

### Proposed fix
- Include `"name": name` in the PATCH payload for existing projects.
- Consider checking the PATCH response code and recording a warning if it fails.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread app/scheduler.py Outdated
Comment thread app/routers/backup.py Outdated
Comment thread scripts/restore.py Outdated
Comment thread scripts/restore.py Outdated
Comment thread scripts/restore.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (4)
scripts/restore.py (3)

302-311: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

הקדימו את בדיקת פרטי ההתחברות לפני האישור.

בדיקת --email/--password נמצאת אחרי שאלת האישור. המשתמש קורא את אזהרת replace, מאשר, ורק אז מקבל "חסרים --email/--password". העבירו את הבדיקה לפני שורה 302.

Claude Code כתב כאן זרימה נעימה לקריאה, וברירת המחדל השלילית ב-[y/N] היא בחירה נכונה. זו רק שאלה של סדר.

♻️ סדר מוצע
+    if not args.email or not args.password:
+        print("חסרים --email/--password או ADMIN_EMAIL/ADMIN_PASSWORD")
+        return 1
+
     if not args.yes:
         answer = input("להמשיך? [y/N] ").strip().lower()
         if answer not in ("y", "yes"):
             print("בוטל.")
             return 1
-
-    if not args.email or not args.password:
-        print("חסרים --email/--password או ADMIN_EMAIL/ADMIN_PASSWORD")
-        return 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/restore.py` around lines 302 - 311, Move the
`args.email`/`args.password` validation block before the `if not args.yes`
confirmation flow, so missing credentials are reported before prompting the
user. Preserve the existing default-negative `[y/N]` behavior and cancellation
return path.

60-68: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

הוסיפו אבחון לתשובת הפניה (redirect) בכניסה.

הלקוח מוגדר עם follow_redirects=False. אם --url מפנה מחדש (למשל http:// אל https://, או כתובת עם סיומת שונה), הכניסה מחזירה 301/307. ההודעה הנוכחית מדווחת "הכניסה נכשלה" ומכוונת את המשתמש לבדוק מייל וסיסמה, וזה מסתיר את הסיבה האמיתית. הוסיפו הודעה נפרדת למקרה של הפניה.

♻️ שיפור מוצע להודעת השגיאה
         if response.status_code != 200:
+            if response.is_redirect:
+                target = response.headers.get("location", "")
+                sys.exit(
+                    f"הכתובת מפנה מחדש אל {target}. הריצו שוב עם הכתובת הסופית ב---url."
+                )
             sys.exit(f"הכניסה נכשלה ({response.status_code}). בדקו כתובת, מייל וסיסמה.")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/restore.py` around lines 60 - 68, Update login in the response-status
handling to detect redirect status codes and emit a separate message identifying
the redirect and its target, instead of treating them as credential failures;
preserve the existing generic failure message for non-redirect statuses.

109-115: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

האחידו את הטיפול בארכיון פגום.

הסקריפט מסיים כל תקלה אחרת בהודעה בעברית, אבל json.loads על manifest.json וגם decode("utf-8") על המסמכים מעלים חריגה גולמית. משתמש שמקבל ארכיון חתוך רואה traceback במקום הסבר. עטפו את קריאת המניפסט ואת פענוח התוכן בהודעת שגיאה מפורשת.

הערה נוספת: archive.testzip() מפרק את כל חברי הארכיון לצורך בדיקת CRC, והמסמכים נקראים שוב אחר כך. עבור גיבוי גדול זו עבודה כפולה. אם זה חשם לכם — אפשר להסתפק בבדיקת ה-CRC הנעשית ממילא בכל archive.read.

♻️ תיקון מוצע לקריאת המניפסט
     manifest = {}
     if "manifest.json" in names:
-        manifest = json.loads(archive.read("manifest.json"))
+        try:
+            manifest = json.loads(archive.read("manifest.json"))
+        except (json.JSONDecodeError, UnicodeDecodeError):
+            sys.exit("manifest.json בארכיון אינו JSON תקין.")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/restore.py` around lines 109 - 115, אחדו את טיפול השגיאות בזרימת
קריאת archive: עטפו את json.loads בעת טעינת manifest.json ואת decode("utf-8")
בעת קריאת המסמכים, והציגו הודעת שגיאה מפורשת בעברית במקום traceback. הסירו את
בדיקת archive.testzip() המוקדמת, כך שבדיקות ה-CRC יתבצעו במסגרת archive.read
הקיימת ללא קריאה כפולה.
tests/test_restore.py (1)

42-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

תקנו את ה-docstring, והשתמשו ב-calls או הסירו אותו.

שני פרטים בקטע הזה:

  • שורה 44 קובעת "הסקריפט סינכרוני; הבדיקות אסינכרוניות". זה לא נכון: כל המתודות ב-Client שב-scripts/restore.py הן async def, וה-docstring שם (שורות 48-50) מסביר במפורש שהוא אסינכרוני כדי שאותה restore תרוץ גם מהבדיקות. ההסבר הנוכחי מטעה את מי שיקרא את המחלקה הזאת בפעם הראשונה. הסיבה האמיתית לקיומה היא ניתוב אל ה-ASGI client והוספת כותרות WRITE.
  • self.calls נאסף בכל מתודה ואף בדיקה לא קוראת אותו. או שתסירו אותו, או שתשתמשו בו כדי לאמת את המצבים — למשל שב-skip על פרויקט קיים אין בכלל PUT.
♻️ תיקון מוצע ל-docstring
-    """מתרגם את ממשק ה-Client של הסקריפט ל-httpx האסינכרוני של הבדיקות.
-
-    הסקריפט סינכרוני; הבדיקות אסינכרוניות. במקום להרים שרת, כל קריאה
-    מנותבת לאותו client שכל שאר הבדיקות משתמשות בו — כלומר מה שנבדק
-    הוא הלוגיקה של השחזור, ולא httpx.
-    """
+    """מממש את ממשק ה-Client של הסקריפט מול ה-ASGI client של הבדיקות.
+
+    הסקריפט אסינכרוני, ולכן אותה `restore` רצה כאן בלי גרסה שנייה של
+    הלוגיקה. במקום להרים שרת, כל קריאה מנותבת לאותו client שכל שאר
+    הבדיקות משתמשות בו, עם כותרות WRITE — כלומר מה שנבדק הוא הלוגיקה
+    של השחזור, ולא httpx.
+    """
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_restore.py` around lines 42 - 52, Update the adapter class
docstring to accurately describe routing restore requests through the ASGI
client and adding WRITE headers, without claiming the script is synchronous;
then either remove the unused self.calls tracking from the adapter methods or
add assertions that use it, including verifying skip mode for an existing
project performs no PUT.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/restore.py`:
- Around line 256-260: Update scripts/restore.py lines 256-260 in the
link-restore loop to fetch existing links through GET /links, filter by URL, and
POST only links that are not already present. Update tests/test_restore.py lines
267-281 to add a project link, run the second restore in upsert mode, and assert
the project still has exactly one link.
- Around line 118-131: Update the archive-entry loop around name.partition("/")
to skip any record whose leaf still contains "/", before creating or updating a
project entry. This must ignore nested paths such as alpha/draft/install.md and
helper directories such as __MACOSX/ while preserving processing of flat
links.json and Markdown document entries.
- Around line 269-270: Remove the --password argument from the argument parser
and obtain the admin password only from ADMIN_PASSWORD or getpass before the
login flow. Update the validation and subsequent authentication logic to use the
resolved password variable, while preserving the existing --email/ADMIN_EMAIL
handling and --yes behavior.
- Around line 180-215: בדקו את תגובת DELETE במסלול replace לפני לקבוע
project_exists = False ולהמשיך ליצירה; عند כשל, הוסיפו אזהרה מתאימה ל־warnings
והמשיכו לטפל בשחזור לפי הזרימה הקיימת. בדקו גם את תגובת PATCH במסלול שבו הפרויקט
קיים, והוסיפו אזהרה ל־warnings כאשר עדכון description או visibility נכשל, תוך
שמירת actual_slug והמשך עיבוד הפרויקט.

In `@tests/test_restore.py`:
- Around line 95-115: Replace the duplicated archive parsing in the test helper
_read with the production read_archive function from scripts/restore.py, writing
the raw bytes to a temporary path and calling read_archive on it. Update each
test to use the read fixture as read(raw) instead of _read(raw), add the
required itertools import, and remove zipfile/json imports only if unused
afterward.

---

Nitpick comments:
In `@scripts/restore.py`:
- Around line 302-311: Move the `args.email`/`args.password` validation block
before the `if not args.yes` confirmation flow, so missing credentials are
reported before prompting the user. Preserve the existing default-negative
`[y/N]` behavior and cancellation return path.
- Around line 60-68: Update login in the response-status handling to detect
redirect status codes and emit a separate message identifying the redirect and
its target, instead of treating them as credential failures; preserve the
existing generic failure message for non-redirect statuses.
- Around line 109-115: אחדו את טיפול השגיאות בזרימת קריאת archive: עטפו את
json.loads בעת טעינת manifest.json ואת decode("utf-8") בעת קריאת המסמכים, והציגו
הודעת שגיאה מפורשת בעברית במקום traceback. הסירו את בדיקת archive.testzip()
המוקדמת, כך שבדיקות ה-CRC יתבצעו במסגרת archive.read הקיימת ללא קריאה כפולה.

In `@tests/test_restore.py`:
- Around line 42-52: Update the adapter class docstring to accurately describe
routing restore requests through the ASGI client and adding WRITE headers,
without claiming the script is synchronous; then either remove the unused
self.calls tracking from the adapter methods or add assertions that use it,
including verifying skip mode for an existing project performs no PUT.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4117c434-88e3-4853-9398-5c4cc105ca8b

📥 Commits

Reviewing files that changed from the base of the PR and between 030096b and eac31f0.

📒 Files selected for processing (7)
  • ROADMAP.md
  • app/routers/backup.py
  • app/scheduler.py
  • index.html
  • scripts/restore.py
  • tests/test_restore.py
  • tests/test_scheduler.py

Comment thread scripts/restore.py
Comment thread scripts/restore.py
Comment thread scripts/restore.py
Comment thread scripts/restore.py Outdated
Comment thread tests/test_restore.py Outdated
הממצא המהותי: השחזור דיווח הצלחה על מסמכים שלא נכתבו. השרת מחזיר 200 עם
applied=false כשהוא דוחה כתיבה שאינה מתקדמת מאותו עורך, והסקריפט השתמש
ב-editor_id קבוע ובדק סטטוס בלבד. כלומר שחזור שני של אותו מסמך נדחה
בשקט ונספר כהצלחה. שוחזר מול שרת חי לפני התיקון. עכשיו מזהה עורך ייחודי
לכל ריצה, ובדיקת applied — כתיבה שנדחתה נספרת כאזהרה ולא כהצלחה.

עוד בשחזור:
- קישורים שוכפלו בכל הרצה חוזרת ב-upsert. אין אילוץ ייחודיות שימנע את
  זה, ולכן הקיימים נקראים ומסוננים לפי URL. גם position נשמר עכשיו —
  בלעדיו הסדר שהכותב קבע אבד.
- upsert לא יישר את שם הפרויקט, ולכן שם שהשתנה אחרי הגיבוי שרד שחזור
  שאמור להחזיר את מה שגובה.
- תגובות DELETE ו-PATCH נבדקות. מחיקה שנכשלה לפני replace הובילה ליצירה
  שנכשלת ב-409, בזמן שהפלט מדווח על שחזור.
- --password הוסר. ארגומנטים נראים ב-ps ונשמרים בהיסטוריית ה-shell,
  והסיסמה נקראת מ-ADMIN_PASSWORD או מ-getpass.
- נתיבים מקוננים ותיקיות __MACOSX מדולגים. הארכיון שלנו שטוח, אבל הקובץ
  מגיע מבחוץ.
- שגיאות קריאה מוצגות בעברית במקום traceback.

ב-API: backup_status עשה שני מעברי stat לכל קובץ בלי טיפול בשגיאה, וקובץ
שנמחק בין המעברים היה מחזיר 500 מהמסך שאמור לדווח על מצב הגיבוי. עכשיו
מעבר אחד עם דילוג על מה שנעלם. stamp_of הפך לציבורי במקום שימוש חיצוני
בפרטי.

על הנעילה — הביקורת ביקשה try-acquire במקום בדיקה ואז תפיסה. ניסיתי,
ו-wait_for(acquire(), timeout=0) מבטל את המשימה לפני שהיא רצה: הוא נכשל
תמיד, גם כשהנעילה פנויה, ושום גיבוי לא היה רץ יותר. הבדיקה שיורה 20
קריאות במקביל תפסה את זה בתלייה. ל-asyncio.Lock אין try-acquire, והצירוף
הקיים בטוח כי acquire אינו משתהה כשהנעילה פנויה — נשאר כפי שהיה, עם
נימוק כתוב ובדיקה שמוכיחה אותו במקום להסתמך על הנימוק.

בבדיקות: עוזר הקריאה בטסטים היה עותק של לוגיקת הפרסור, והוחלף בקריאה
ל-read_archive עצמה. נוספו מסלולי הכשל שביקשה הביקורת — קובץ שאינו ZIP,
סיסמה שגויה, CRC שבור, נתיבים מקוננים — וכן מצב בלי גיבויים כלל, כשל
שמתורגם ל-500, וקובץ שנעלם באמצע הקריאה.

209 בדיקות pytest. check-ui, check-editor, check-backup, check-offline —
כולם עברו.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpBDJFA5w5AkP2YQM51JiS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants